G1 垃圾收集器

说是整理基本就是白嫖 R大 的整理。

G1 的 GC模式

G1 收集器的 GC 默认可以分为:Young GC 以及 Mixed GC

R大的解释

Young GC 就是对年轻代的 GC,而 Mixed GC 是在 Young GC 的基础上加上收益较高的部分 Old 区。

所以 Young 区每次 GC 都会被处理。

从最高层看,G1 的 collector 一侧其实就是两个大部分:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)

而这两部分可以相对独立的执行。


Global concurrent marking 基于 SATB 形式的并发标记。

SATB 的标记形式就是指保存所有在标记期间的引用关系的变更,在并发结束后重新扫描。

它具体分为下面几个阶段:

  1. 初始标记(initial marking)暂停阶段。扫描根集合,标记所有从根集合可直接到达的对象并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1 使用外部的 bitmap 来记录 mark 信息,而不使用对象头的 mark word 里的 mark bit。在分代式G1模式中,初始标记阶段借用 young GC 的暂停,因而没有额外的、单独的暂停阶段。
  2. 并发标记(concurrent marking):并发阶段。不断从扫描栈取出引用递归扫描整个堆里的对象图。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描 SATB write barrier 所记录下的引用。
  3. 最终标记(final marking,在实现中也叫 remarking )暂停阶段。在完成并发标记后,每个 Java 线程还会有一些剩下的 SATB write barrier 记录的引用尚未处理。这个阶段就负责把剩下的引用处理完。同时这个阶段也进行弱引用处理(reference processing)。 注意这个暂停与 CMS 的 remark 有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描 mod-union table 里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢。
  4. 清理(cleanup) 暂停阶段。清点和重置标记状态。这个阶段有点像 mark-sweep 中的 sweep 阶段,不过不是在堆上 sweep 实际对象,而是在marking bitmap 里统计每个 region 被标记为活的对象有多少。这个阶段如果发现完全没有活对象的 region 就会将其整体回收到可分配 region 列表中。

清理阶段并不是回收垃圾,而是在 bitmap 中做标记!

Evacuation阶段是全暂停的。它负责把一部分region里的活对象拷贝到空region里去,然后回收原本的region的空间。

Evacuation 阶段可以自由选择任意多个 region 来独立收集构成收集集合(collection set,简称CSet),靠 per-region remembered set(简称RSet)实现。这是 regional garbage collector 的特征。

根据计算的 GC 收益选择较高的区域进行收集,CSet 表示收集的目标区域,而 RSet 是保存收益较高的区域集合。

在选定 CSet 后,evacuation 其实就跟 ParallelScavenge 的 young GC 的算法类似,采用并行 copying(或者叫 scavenging)算法把 CSet 里每个 region 里的活对象拷贝到新的 region 里,整个过程完全暂停。

所以在小内存的时候,G1 和 CMS 对比并没有明显的优势,因为在最终的收集阶段,G1 是 STW 的,而 CMS 是并发的,所以 CMS 会有浮动垃圾的问题(无法完全清理垃圾。

从这个意义上说,G1 的 evacuation 跟传统的 mark-compact 算法的 compaction 完全不同:前者会自己从根集合遍历对象图来判定对象的生死,不需要依赖 global concurrent marking 的结果,有就用,没有拉倒;而后者则依赖于之前的 mark 阶段对对象生死的判定(?)。

G1 的 evacuation 阶段可以依据之前 RSet 的记录直接进行,只要事先选择 CSet,而 mark-compact 的 compaction 是 mark 的下个阶段,必须先 compaction。

论文里提到的纯 G1 模式下,CSet的选定完全靠统计模型找处收益最高、开销不超过用户指定的上限的若干 region。由于每个 region 都有 RSet 覆盖,要单独 evacuate 任意一个或多个region都没问题。

分代式G1模式下有两种选定 CSet 的子模式,分别对应 young GC与 mixed GC:

  • Young GC:选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。
  • Mixed GC:选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。在用户指定的开销目标范围内尽可能选择收益高的old gen region。

可以看到 young gen region 总是在 CSet 内。因此分代式 G1 不维护从 young gen region 出发的引用涉及的RSet更新。

分代式 G1 的正常工作流程就是在 young GC 与 mixed GC 之间视情况切换,背后定期做做全局并发标记。Initial marking 默认搭在 young GC 上执行;

G1 的两个阶段是可以拆开的,定期做全局的并发标记并且记录在 RSet 中。

当全局并发标记正在工作时,G1 不会选择做 mixed GC,反之如果有 mixed GC 正在进行中 G1 也不会启动 initial marking。

在正常工作流程中没有 full GC 的概念,old gen的收集全靠mixed GC来完成。

如果 mixed GC 实在无法跟上程序分配内存的速度,导致 old gen 填满无法继续进行mixed GC,就会切换到 G1 之外的 serial old GC 来收集整个GC heap(注意,包括young、old、perm),这才是真正的full GC。

Full GC 之所以叫 full 就是要收集整个堆,只选择 old gen 的部分 region 算不上 full GC,进入这种状态的G1就跟 -XX:+UseSerialGC 的 full GC 一样(背后的核心代码是两者共用的 )。 顺带一提,G1 GC 的 System.gc() 默认还是 full GC,也就是 serial old GC。只有加上 -XX:+ExplicitGCInvokesConcurrent 时 G1 才会用自身的并发 GC 来执行 System.gc() ——此时 System.gc() 的作用是强行启动一次 global concurrent marking;一般情况下暂停中只会做 initial marking 然后就返回了,接下来的 concurrent marking 还是照常并发执行。

然后 G1 在 mutator 一侧需要使用 write barrier 来实现:

  • SATB snapshot的完整性
  • 跨region的引用记录到RSet里。

这两个动作都使用了 logging barrier,其处理有一部分由 collector 一侧并发执行。

可以看到在这么多步骤里,G1 只有两件事是并发执行的:(1) 全局并发标记;(2) logging write barrier 的部分处理

而“拷贝对象”(evacuation)这个很耗时的动作却不是并发而是完全暂停的。那G1为何还可以叫做低延迟的 GC 实现呢?

重点就在于 G1 虽然会mark整个堆,但并不 evacuate 所有有活对象的 region

G1 单论吞吐量不一定高于 CMS,G1 的低延迟实现依靠的是 regional 的处理,尽量选择收益较高的区域,而非整个 Old Gen。

通过只选择收益高的少量 region 来 evacuate,这种暂停的开销就可以(在一定范围内)可控。

每次 evacuate 的暂停时间应该跟一般 GC 的 young GC 类似,所以 G1 把自己标榜为“软实时”(soft real-time)的 GC。

但是毕竟要暂停来拷贝对象,这个暂停时间再怎么低也有限。

G1 的 evacuation pause 在几十到一百甚至两百毫秒都很正常,所以切记不要把 -XX:MaxGCPauseMillis 设得太低,不然G1跟不上目标就容易导致垃圾堆积,反而更容易引发 full GC 而降低性能。通常设到100ms、250ms之类的都可能是合理的。设到50ms就不太靠谱,G1可能一开始还跟得上,跑的时间一长就开始乱来了。 这也提醒大家:如果您的程序要长时间运行,那么在技术选型评估 GC 性能的时候要让测试程序跑足够长时间才能看清状况。多久才够长取决于实际应用要连续运行多久。不然一个要运行一个月才重启一次的程序,如果测试的时候只测了两个小时就觉得没问题,实际上线跑起来可能正好两个半小时的时候来了一次几分钟的 full GC 暂停,那就纱布了⋯

G1 的关键点!!!不要过度追求低延时,低延时势必导致低垃圾回收效率,分配速度大于垃圾回收效率最终会触发 Full GC(Serial GC)。

G1需要暂停来拷贝对象,而 CMS 在暂停中只需要扫描(mark)对象,那算法上 G1 的暂停时间会比 CMS 短么? 其实 CMS 在较小的堆、合适的 workload 的条件下暂停时间可以很轻松的短于 G1。在2011年的时候 Ramki 告诉我堆大小的分水岭大概在10GB~15GB左右:以下的 -Xmx 更适合 CMS,以上的才适合试用 G1。现在到了 2014年,G1 的实现经过一定调优,大概在6GB~8GB也可以跟 CMS 有一比,我之前见过有在 -Xmx4g 的环境里 G1 比 CMS 的暂停时间更短的案例。 合适的 workload:CMS 最严重的暂停通常发生在 remark 阶段,因为它要扫描整个根集合,其中包括整个 young gen。如果在 CMS 的并发标记阶段,mutator 仍然在高速分配内存使得 young gen 里有很多对象的话,那 remark 阶段就可能会有很长时间的暂停。Young gen 越大,CMS remark 暂停时间就有可能越长。所以这是不适合 CMS 的 workload。相反,如果 mutator 的分配速率比较温和,然后给足时间让并发的 precleaning 做好 remark 的前期工作,这样 CMS 就只需要较短的 remark 暂停,这种条件下 G1 的暂停时间很难低于 CMS。

Collection Set

它记录了 GC 要收集的 Region 集合,集合里的 Region 可以是任意年代的。

不论是 YGC 还是 Mixed GC ,young generation 都包含在 CSet 里面。

如何解决跨代引用以及不同 Region 之间的引用

G1 在 Region 上都绑定了两个对象:

  1. Card Table

Card Table 使用类似 Bitmap 的形式,表示当前区域的对象对外的引用,算是对 Remember Set 的一种实现。

例如在年轻代 GC (单纯的年轻代 GC)的时候,如果没有 Card Table,则需要扫描全部的 Old 区来确定 Young 区的对象是否存活。

此时的 Card Table 标记的老年代的某块区域是否包含指向年轻代的引用,因此扫描的时候只需要从 GC Root + Card Table 中的 Dirty Card 触发判断。

  1. Remember Set

Remembered Set

R大的解释

Remembered Set 是一种抽象概念,而 card table 可以是 remembered set 的一种实现方式。

Remembered Set 是在实现部分垃圾收集(partial GC)时用于记录从非收集部分指向收集部分的指针的集合的抽象数据结构。

分代式 GC 是一种部分垃圾收集的实现方式。当分两代时,通常把这两代叫做 young gen 和 old gen;通常能单独收集的只是 young gen。此时 remembered set 记录的就是从 old gen 指向 young gen 的跨代指针。

Regional collector 也是一种部分垃圾收集的实现方式。此时 remembered set 就要记录跨region的指针。

RSet 从来不会保存从年轻代出发的引用,也可以说是指保存 old -> young 以及 old -> old 的引用路径。

逻辑上来说,每个 Region 都会有一个 RSet。

YGC 的时候,只需要选定 young generation region 的 RSet 作为根集,这些 RSet 记录了 old->young 的跨代引用,避免了扫描整个 old generation。

mixed gc 的时候,old generation 中记录了old->old 的 RSet,young->old 的引用由扫描全部 young generation region 得到,这样也不用扫描全部 old generation region。

参考资料

Tips for Tuning the Garbage First Garbage Collector

R大版本 - G1 收集器的原理

results matching ""

    No results matching ""